# Copyright (c) 2019 Presto Labs Pte. Ltd.
# Author: taekwon(originally from coinone_v2/order_gateway.py)

import enum
import typing

import requests
import tornado.gen

import coin.flow.subscriber as flow_subscriber
import coin.flow.topic_map as topic_map

from coin.base.config import Config
from coin.exchange.base.fill_checker_order_info import from_proto_order_side
from coin.exchange.base.order_fill_util import FillManager
from coin.exchange.base.rest.task_scheduler import Scheduler, wrap_async_task
from coin.exchange.gopax.kr_rest.private_client import GopaxPrivateParser
from coin.exchange.gopax.kr_rest.product import GopaxProduct, GopaxCurrency
from coin.exchange.util.rate_limit import RateLimiter
from coin.exchange.util.rest_rate_limit import RestRateLimiter
from coin.proto.coin_order_gateway_pb2 import OrderGatewayConfig, OrderEvent

from coin.exchange.gopax.kr_rest.native_private_client import (
    GopaxNativePrivateClient,
    is_success_response_msg,
)
from coin.exchange.base.rest_order_gateway_base import (
    BaseBalanceOrderGatewayInfo,
    RestOrderGateway,
    PrivateExchangeMessage,
    exchange_message_handler,
    create_order_internal,
    Order,
    OrderSide,
    OrderType,
)


def create_order_from_proto(proto_order, timestamp, order_id=None):
  price_orig = proto_order.price
  qty_orig = proto_order.qty
  order_side = from_proto_order_side(proto_order.side)
  order_type = OrderType.LIMIT
  product = GopaxProduct.FromStrProduct(proto_order.symbol)
  exchange_order_id = proto_order.exchange_order_id

  fill = FillManager(
      qty_orig=qty_orig,
      price_orig=price_orig,
      qty_accum_fill=0.0,
      price_avg_fill=None,
      qty_last_fill=None,
      price_last_fill=None,
  )

  order = Order(order_id=order_id,
                product=product,
                price=price_orig,
                qty=qty_orig,
                order_side=order_side,
                order_type=order_type,
                tag=None,
                accepted=True,
                cancel_sent=False,
                internal=create_order_internal(timestamp=timestamp,
                                               exchange_order_id=exchange_order_id,
                                               fill=fill))
  return order


class GopaxOrderGatewayInfo(BaseBalanceOrderGatewayInfo):
  CurrencyType = GopaxCurrency
  ProductType = GopaxProduct

  def update_balance(self, update_msg):
    account_balance = GopaxPrivateParser.parse_balance(update_msg)
    for balance in account_balance.each_balance:
      currency = GopaxCurrency.FromStrCurrency(balance.currency)
      if currency in self.currencies:
        self.set_balance(currency, balance.total, balance)


class GopaxOrderGateway(RestOrderGateway):
  _exchange = 'Gopax'
  _market_type = 'Spot'
  _api_version = None

  OGInfoType = GopaxOrderGatewayInfo

  class _MsgType(enum.IntEnum):
    REST_ACCOUNT_BALANCE = 1
    REST_LIMIT_ORDERS = 2
    REST_TRADES = 3
    REST_ORDER_INFO = 4

  def __init__(self,
               currencies: typing.List[GopaxCurrency],
               products: typing.List[GopaxProduct],
               config: Config,
               og_config=None,
               logger=None):
    super().__init__(currencies, products, config, og_config, logger)
    self._private_client = None
    self._scheduler = None
    self._ioloop = None
    self._flow_sub = None
    self._publisher = None

    # If you make more requests than the above notice,
    # you will be blocked for 10 minutes.
    default_og_config = OrderGatewayConfig(
        account_query_period=2,
        orders_snapshot_query_period=5,
        fill_query_period=1,
        hit_rate_limit_cooldown_time=610,
    )
    default_og_config.MergeFrom(self._og_config)
    self._og_config = default_og_config

    # Handle fill message
    self._rest_rate_limiter = RestRateLimiter(delay=5)
    self._order_rate_limiter = RateLimiter(
        self._og_config.order_rate_limit,
        self._og_config.order_rate_limit_per,
    )
    self._first_open_orders_query = set()
    self._first_trades_query = set()

  def start(self, queue, ioloop, flow_sub=None):
    api_host = self._og_config.rest_root_url \
               if self._og_config.HasField('rest_root_url') else None
    self._private_client = GopaxNativePrivateClient(
        key_file=self._config.key_file,
        max_connections=self._og_config.max_connections,
        api_host=api_host,
        use_async=True,
        rest_request_gap_sec=self._og_config.rest_request_gap_sec,
    )
    topic_id = topic_map.search_topic_id('gopax_rest_private_1')[0][1]
    self._ioloop = ioloop
    self._publisher = queue.get_writer(topic_id=topic_id)
    self._scheduler = Scheduler(ioloop=ioloop, logger=self._logger)
    self._install_periodic_query()

    # Subscribe to flow for feed.
    self._flow_sub = flow_sub or flow_subscriber.from_queue(queue)
    self._flow_sub.subscribe('gopax_rest_private_1', self.on_private_exchange_msg)
    self._logger.info(
        'order_rate_limit: %s, order_rate_limit_per: %s',
        self._og_config.order_rate_limit,
        self._og_config.order_rate_limit_per,
    )

  def submittable(self):
    return self.is_ready() \
           and self._rest_rate_limiter.is_available() \
           and self._order_rate_limiter.check()

  @tornado.gen.coroutine
  def _install_periodic_query(self):
    assert self._scheduler is not None, 'self._scheduler is not set!'
    assert self._publisher is not None, 'self._publisher is not set!'

    # query balance
    query_balance_task = wrap_async_task(
        self._private_client.query_account_balance,
        msg_type=self._MsgType.REST_ACCOUNT_BALANCE,
        publisher=self._publisher,
    )
    self._scheduler.add_periodic_task(period=self._og_config.account_query_period,
                                      job_func=query_balance_task)

    # query active orders
    for product in self._og_info.products:
      yield tornado.gen.sleep(self._og_config.burst_query_avoid_time)
      query_orders_task = wrap_async_task(
          self._private_client.query_orders,
          msg_type=self._MsgType.REST_LIMIT_ORDERS,
          publisher=self._publisher,
          func_params=dict(currency=product.native_symbol),
      )
      self._scheduler.add_periodic_task(
          period=self._og_config.orders_snapshot_query_period,
          job_func=query_orders_task,
      )

      yield tornado.gen.sleep(self._og_config.burst_query_avoid_time)
      query_orders_task = wrap_async_task(
          self._private_client.query_trades,
          msg_type=self._MsgType.REST_TRADES,
          publisher=self._publisher,
          func_params=dict(currency=product.native_symbol),
      )
      self._scheduler.add_periodic_task(
          period=self._og_config.fill_query_period,
          job_func=query_orders_task,
      )
    self.start_scheduler()

  @tornado.gen.coroutine
  def _on_hit_order_rate_limit(self):
    if not self.is_ready():
      return

    self._og_ready.set_reinit()
    self._scheduler.stop()
    self._logger.info('Hit rate limit, disable OG for % seconds.',
                      self._og_config.hit_rate_limit_cooldown_time)
    yield tornado.gen.sleep(self._og_config.hit_rate_limit_cooldown_time)
    self._scheduler.start()
    self._logger.info('Gopax OG is active again.')

  def _async_submit_order(self, order, post_only=False):
    if post_only:
      self._logger.debug('post only not supported.')
    assert order.order_id is not None, order
    assert order.order_type == OrderType.LIMIT, order

    if float(order.price) * float(order.qty) < 500:
      self._logger.info('Minimal order amount should greater than 500KRW.')
      return

    formatter = self._order_formatter[order.product]
    side = None
    if order.order_side == OrderSide.BUY:
      side = 'buy'
    elif order.order_side == OrderSide.SELL:
      side = 'sell'
    else:
      raise ValueError('Invalid order side : %s', order.order_side)
    params = {
        'currency': order.product.native_symbol,
        'side': side,
        'price': formatter.format_price(order.price),
        'qty': formatter.format_qty(order.qty),
    }
    return self._private_client.place_limit_order(**params)

  def _async_cancel_order(self, order):
    if order.internal.exchange_order_id is None:
      self._logger.error('Order not accepted yet! %s', order.order_id)
      return

    exchange_order_id = order.internal.exchange_order_id
    params = {'order_id': exchange_order_id}
    fut = self._private_client.cancel_order(**params)
    return fut

  def _handle_error_response(self, response: requests.Response):
    if response.status_code >= 500:
      self._logger.error('Gateway error! %s')
    elif response.status_code == 429:
      self._rest_rate_limiter.set_error(response.status_code)
      self._logger.error('Rate limit hit (429)!')
      self._on_hit_order_rate_limit()

    self._logger.error(
        'response error! %s, %s, %s, %s',
        response.status_code,
        response.content,
        response.url,
        response.request.body,
    )
    self.log_json_message(dict(response.headers), msg='Response header')

  def _handle_submit_response(self, order, response):
    """
    {
      "id": "98723",
      "price": 750000,
      "amount": 9,
      "tradingPairName": "ETH-KRW",
      "side": "buy",
      "type": "limit",
      "createdAt": "2018-01-09T10:43:10.000Z"
    }
    """
    if response.status_code != 200:
      self._handle_error_response(response)
      self._order_manager.remove_order(order, ignore_error=True)
      return OrderEvent.ORDER_REJECTED

    msg = response.json()
    if is_success_response_msg(msg):
      exchange_order_id = msg['id']
      prev_order = self._order_manager.get(exchange_order_id=exchange_order_id)
      if prev_order is None:
        self._order_manager.set_order(order, exchange_order_id=exchange_order_id)
      else:
        self._logger.info('order id replaced: %s => %s', prev_order.order_id, order.order_id)
        self._order_manager.remove_order(order)
        self._order_manager.remove_order(prev_order)
        prev_order.order_id = order.order_id
        self._order_manager.add_order(prev_order)
      return OrderEvent.ORDER_ACCEPTED
    else:
      self._order_manager.remove_order(order, ignore_error=True)
      self._logger.error('Submit order Error! %s', msg)
      return OrderEvent.ORDER_ERROR

  def _handle_cancel_response(self, order, response):
    """ Cancel response has a very simple form:
    {
    }

    order id cannot be found
    {
      "errorMessage":"No such order ID",
      "errorCode":202,
      "errorData":"Order server error: 202"
    }
    """
    if response.status_code != 200:
      self._handle_error_response(response)
      order.cancel_sent = False
      msg = response.json()
      if int(msg['errorCode']) == 202 and not self._order_manager.is_dead_order(order):
        # Doesn't hurt to set fully_filled here. just put the order into dead state
        # to prevent the executor cancel this order again.
        self._order_manager.set_order(order, fully_filled=True)
        order.cancel_sent = True
      return OrderEvent.CANCEL_ERROR

    msg = response.json()
    if is_success_response_msg(msg):
      if not self._order_manager.is_dead_order(order):
        self._order_manager.set_order(order, cancel_confirmed=True)
      else:
        self._logger.info('Order %s is dead already!', order.order_id)
      return OrderEvent.CANCEL_CONFIRMED
    else:
      order.cancel_sent = False
      self._logger.error('Order is error! %s, %s', order.order_id, msg)
      return OrderEvent.CANCEL_ERROR

  @exchange_message_handler(_MsgType.REST_ACCOUNT_BALANCE)
  def _handle_account_balance(self, exchange_message: PrivateExchangeMessage):
    response = exchange_message.data
    if response.status_code != 200:
      self._handle_error_response(response)
      return

    msg = response.json()
    if not is_success_response_msg(msg):
      self._logger.error('Invalid response msg: %s', msg)
      return

    self._og_info.update_balance(msg)
    self._og_logger.write_balance(self._og_info.balance_as_proto())

    if not self.is_ready():
      self._og_ready.set_balance_ready(True)
      self._og_ready.check_ready()

  @exchange_message_handler(_MsgType.REST_LIMIT_ORDERS)
  def _handle_open_orders(self, exchange_message: PrivateExchangeMessage):
    response = exchange_message.data
    timestamp = exchange_message.received_timestamp
    if response.status_code != 200:
      self._handle_error_response(response)
      return

    msg = response.json()
    if not is_success_response_msg(msg):
      self._logger.error('Invalid response msg! %s', msg)
      return

    native_symbol = exchange_message.request_params['currency']
    product = GopaxProduct.FromStrNativeProduct(native_symbol)
    if product not in self._og_info.products:
      self._logger.error('Unexpected response, product not in og_info!')
      return

    proto_orders = GopaxPrivateParser.parse_orders(msg, product)
    exchange_order_dict = {}
    for proto_order in proto_orders.each_order:
      order = create_order_from_proto(proto_order, timestamp)
      exchange_order_id = order.internal.exchange_order_id
      prev_order = self._order_manager.get(exchange_order_id=exchange_order_id)
      if prev_order is None:
        order_id = self._order_id_generator.gen_order_id(is_foreign=True)
        order.order_id = order_id
      else:
        order.order_id = prev_order.order_id
      exchange_order_dict[exchange_order_id] = order

    self._order_manager.copy_from_exchange_open_orders(exchange_order_dict=exchange_order_dict,
                                                       products=[product])

    if not self.is_ready():
      self._first_open_orders_query.add(product.symbol)
      if len(self._first_open_orders_query) == len(self._og_info.products):
        self._og_ready.set_orders_ready(True)
        self._og_ready.check_ready()

  @exchange_message_handler(_MsgType.REST_TRADES)
  def _handle_trades(self, exchange_message: PrivateExchangeMessage):
    response = exchange_message.data
    timestamp = exchange_message.received_timestamp
    if response.status_code != 200:
      self._handle_error_response(response)
      return

    msg = response.json()
    if not is_success_response_msg(msg):
      self._logger.error('Invalid response msg! %s', msg)
      return

    native_symbol = exchange_message.request_params['currency']
    product = GopaxProduct.FromStrNativeProduct(native_symbol)
    if product not in self._og_info.products:
      self._logger.error('Unexpected trade response! %s', msg)
      return

    is_first_fill_query = native_symbol not in self._first_trades_query
    if is_first_fill_query:
      self._first_trades_query.add(native_symbol)

    fill_bundle_proto = GopaxPrivateParser.parse_fill(msg, product)

    for fill_proto in fill_bundle_proto.each_fill:
      if is_first_fill_query:
        self._processed_fills.add(fill_proto.fill_id)
      self._update_by_fill(fill_proto, timestamp)

  def _update_by_fill(self, fill_proto, timestamp):
    if fill_proto.fill_id in self._processed_fills:
      return

    exchange_order_id = fill_proto.exchange_order_id
    order = self._order_manager.get(exchange_order_id=exchange_order_id)
    if order is None:
      self._logger.error('Fill exchange order id not found!\n%s', fill_proto)
      return

    self._processed_fills.add(fill_proto.fill_id)
    last_fill = order.internal.fill.update_by_last_fill(fill_proto.price, fill_proto.qty)
    if last_fill is None:
      return

    if order.internal.fill.fully_filled and not self._order_manager.is_dead_order(order):
      self._order_manager.set_order(order, fully_filled=True)

    fill_type = self._guess_fill_type(order, last_fill.price_last_fill, fill_proto.fill_timestamp)
    fill_proto.guess_fill_type = fill_type
    self.process_fill(fill_proto, timestamp)
